iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
JavaScript

QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》系列 第 19 篇

Day019|非同步:畫面凍住的 3 秒,我自己放棄了點擊

  • 分享至 

  • xImage
  •  

今天的主題:非同步——「未來會有結果」的承諾

Day 17 我已經摸到門了:setTimeout 預約一件事,程式不會停下來等。那個「排到待辦清單最後面」的機制,今天變成主角。

現實中有些事天生就要等:跟伺服器要資料、讀一個大檔案、等使用者回應。如果程式「停在那裡等」,整個畫面會凍住。所以 JavaScript 的做法是:先去做別的事,等結果來了再回頭處理 —— 這就是非同步(asynchronous)。

階段一:回呼地獄

最原始的寫法是「做完之後,執行我給你的這個函式」:

console.log("開始煮飯");
setTimeout(() => {
  console.log("1. 洗米好了");
  setTimeout(() => {
    console.log("2. 下鍋了");
    setTimeout(() => {
      console.log("3. 煮好了");
      // ...
    }, 500);
  }, 500);
}, 500);

我故意多加了兩層,寫到第六步的時候,縮排已經退到螢幕很裡面了。這個現象有個名字叫回呼地獄(callback hell),也有人叫它「末日金字塔」—— 因為程式碼的形狀真的像一座倒過來的金字塔。

而且不只難看:如果第三步出錯了要怎麼處理?每一層都得自己寫錯誤處理,又是一團亂。

階段二:Promise

Promise 是「一個未來會有結果的承諾」。我一開始不太懂它跟回呼差在哪,後來想到一個分法:

寫法 誰握著主導權
回呼 setTimeout(函式, 500) 你把函式塞進去別人手裡
Promise wait(500).then(函式) 別人先給你一張憑證,你拿著憑證再說要做什麼

因為主導權在你手上,所以可以一直往後串,不用一層層縮進去:

function wait(ms, message) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log(message);
      resolve();          // 承諾兌現
    }, ms);
  });
}

wait(500, "1. 洗米好了")
  .then(() => wait(500, "2. 下鍋了"))
  .then(() => wait(500, "3. 煮好了"))
  .then(() => wait(500, "4. 上菜!"));

一樣四個步驟,縮排不再往右退,而是排成一直線。

.then 就是「然後」:這個承諾兌現之後,做這件事。而當 .then 裡面又回傳一個 Promise 時,下一個 .then 會等它也完成 —— 這就是四個步驟能接成一條線的原因。

Promise 有三種狀態:

狀態 意思
pending 還在處理中
fulfilled 成功了,有結果
rejected 失敗了,有原因

我自己記的比喻是點餐的號碼牌:你拿到的不是食物,是「等一下會有食物」的憑證。可以先去坐下(程式繼續跑),叫號了再回來拿。

(Day 17 的 setInterval 也給號碼牌,但那是「拆彈編號」—— 拿著它是為了取消;Promise 的號碼牌是「未來會兌現的憑證」,拿著它是為了領東西。同樣是號碼牌,用途相反。)

階段三:async / await

.then 串多了還是長。async/await 讓非同步的程式讀起來像同步:

async function 煮飯() {
  console.log("開始煮飯");
  await wait(500, "1. 洗米好了");
  await wait(500, "2. 下鍋了");
  await wait(500, "3. 煮好了");
  await wait(500, "4. 上菜!");
  console.log("全部完成");
}
煮飯();
console.log("← 這行會先出現");

await 的意思是「等這個承諾完成,再往下」。它只能寫在 async 函式裡。

關鍵是最後那行的位置:它會先出現。await 看起來像「停下來等」,但它只暫停這個函式內部,不會卡住外面的程式。這個差別是整天最重要的一句話。

一個我自己製造的錯覺

跑練習 3 的時候我覺得「怎麼比練習 2 快這麼多」,明明都設 500 毫秒。

後來發現是我同時放開了兩個練習 —— 兩組煮飯流程一起開跑,輸出交錯在一起,看起來就像刷一下全出來了。

這算是自己絆倒自己,但它意外示範了非同步的特性:兩個流程可以同時進行,誰也不等誰。如果是同步的程式,第二組一定要等第一組跑完。

卡住 vs 不卡住:今天真正的重點

前面都是語法。最後這個實驗才是我今天最有感的部分。

我做了兩個按鈕,都是「等 3 秒」,但作法完全不同:

// A. 真的停下來等:用迴圈把執行緒佔住不放
const end = Date.now() + 3000;
while (Date.now() < end) { }

// B. 非同步等待:交出控制權,時間到再回來
await sleep(3000);

旁邊放第三個按鈕,點一下就在主控台印「我被點了 N 次」。實驗方式是:按下等待,然後在那 3 秒內狂點測試按鈕。

A 的結果:整個畫面凍住,點了沒有任何反應。

插入 day19.png:卡住版,點擊訊息在等待結束後才出現

而我在這裡發現一件事,而且是實驗中最真實的一段:因為沒有即時反饋,我自己就停止點擊了。

這正是「卡住」最大的傷害。不是功能壞掉 —— 那些點擊其實沒有消失,它們被排進待辦清單,等執行緒空出來就會處理。壞掉的是使用者的信心:畫面沒反應,人就會認為它死了,然後放棄操作、重新整理、或直接關掉。

B 的結果:等待期間畫面完全正常,每一下點擊都即時有回應。

插入 day19-1.png:非同步版,點擊訊息即時出現

同樣是等 3 秒,一個讓使用者以為程式壞了,一個讓使用者知道程式還活著。

這件事跟 Day 7 是同一個故事

Day 7 那次無窮迴圈把我的電腦搞到風扇狂轉、瀏覽器跳出「網頁沒有回應」。今天的 A 按鈕是同一件事的縮小版 —— 只是它 3 秒後會自己結束,所以沒有那麼戲劇化。

兩者的根源都是:JavaScript 只有一條執行緒。任何一段程式只要不交還控制權,整個分頁就什麼都做不了 —— 不能點、不能捲動、不能重繪。

而 await 做的事就是主動交還控制權:「我要等一下,這段時間你先去忙別的」。

誠實說:這個遊戲用不到非同步

這個小遊戲沒有網路請求、不讀檔案、沒有任何需要等待的事情。今天學的東西,目前一行都用不上。

那為什麼還是花一天學?三個理由:

  1. 之後要做雲端英雄榜(存分數到伺服器),那是非同步的典型場景
  2. 前端幾乎所有「跟外面溝通」的事都是非同步 —— 這是繞不過去的基礎
  3. 那個「卡住 vs 不卡住」的體感,我覺得比語法本身更重要

這是繼 Day 17 之後,第二次我學完一個東西、結論是「知道它是什麼,但這次用不到」。不過這次不一樣:計時器是判斷不適合,非同步是時候未到。

小結

今天搞懂的事:

  • 回呼地獄:一件事接一件事,縮排會退成一座倒金字塔
  • Promise 是「未來會有結果」的憑證;.then 就是「然後」
  • 回呼是把函式塞給別人,Promise 是別人給你憑證 —— 主導權不同,所以能串成直線
  • async/await 讓非同步讀起來像同步;await 只暫停函式內部,不卡住外面
  • 同步的等待會凍住整個畫面,因為 JS 只有一條執行緒
  • 畫面凍住時點擊不會消失,會排隊 —— 但使用者不知道,他們會直接放棄
  • 我自己就是那個放棄的使用者

明天學 localStorage,讓資料在關掉瀏覽器之後還留著。


上一篇
Day018|把散落的變數收進一個盒子
下一篇
Day20|我半年前用來測 API 的那把鑰匙,其實 XSS 兩行就能偷走
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言